Skip to content

feat(flags): read the /flags?v=4 response - #1031

Merged
andehen merged 26 commits into
mainfrom
feat/flags-v3-response
Nov 3, 2026
Merged

andehen merged 26 commits into
mainfrom
feat/flags-v3-response

Conversation

@andehen

@andehen andehen commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

The flags service can answer with a typed record (PostHog/posthog#107732), selected by /flags?v=4 (PostHog/posthog#123366). Clients already send v=3 and arbitrary v values, so only an exact v=4 selects the new record. A v4 record carries one typed value (boolean, string, number, object or null) instead of enabled and variant, plus a structured reason and rule metadata.

This PR moves the SDK to that response and keeps the full record for each flag, while every existing getter and event returns the same values as before for valid flag records. It adds no new public methods and does not change event emission. The typed accessor API (get_boolean_value / _details and the string, number, integer, float and object forms) is split out into #1147, which stacks on this PR and waits for the cross-SDK accessor spec in PostHog/sdk-specs#104.

The reader follows the wire contract published in posthog-sdk-test-harness 1.22.0 (contracts/feature_flag_rules_v2, contract 7.0.0):

  1. Both clients request /flags/?v=4. A record with value is a v4 record. A record without it comes from a server that does not serve v4, which answers v=4 with the older record. Its value is variant, or enabled when there is no variant, its config version is 1, and any rule context in it is ignored. No probing or retry. Each client logs one diagnostic (at info level) the first time it reads such a response. There is deliberately no retry with v=2: the flags service answers a v it does not serve with an older record rather than an error, so a fallback request would only repeat requests that failed for other reasons (an outage, a quota limit), doubling their load and hiding the error.
  2. Unknown fields are ignored. A known field with the wrong JSON type makes that record unreadable, in either record shape; other flags in the response are unaffected. A key member of the wrong type is such a field; a string key that differs from the map key is ignored, and the map key stays the flag key. An unreadable record:
    • is not stored in the flag fallback cache, so a value cached from an earlier good response is still served;
    • reads as disabled when nothing is cached;
    • makes a single-flag getter report what 7.82.1 reported when the response failed: $feature_flag_error: "unknown_error", with no request ID or evaluated-at. So a stale cached value is told apart from a fresh one.
  3. The legacy rendering is derived from value in one place, when the record is parsed: a boolean as is, a string as the variant, null as false, and a number or object as true with the value as its payload. v4 sends metadata.payload: null for number and object flags, so the SDK supplies the payload from the value (the same JSON value, not necessarily the same text), and ignores a payload a record sends with a number or object value. Payloads for boolean and string values are kept as sent. get_feature_flag, get_feature_flag_result, get_feature_flag_payload, get_all_flags(_and_payloads), the snapshot getters, $feature/<key>, $active_feature_flags and $feature_flag_called return the same values for both responses.
  4. Each remotely evaluated flag in an evaluate_flags() snapshot keeps its parsed record internally (typed value, failure state, reason and metadata), for the follow-up accessors. The snapshot keeps one copy per flag and decodes payloads only when they are read.

Notes for reviewers

  • The parsed FeatureFlag keeps the typed value and failure state in private attributes (_value, _failed, _malformed_field), and FlagMetadata keeps the v4 rule metadata in private attributes (_config_version, _rule_type, _rule_id, _variant_key, _forced_variant). They are instance attributes with class-level defaults, not dataclass fields, so repr, equality, hashing, dataclasses.asdict() and the generated SDK reference show only the existing fields (a test pins this). They are private on purpose: get_flags_decision() returns these types, so public fields could be adopted before the typed flag API settles. feat(flags): typed flag value accessors #1147 makes them public together with the typed accessors. No new public fields, types, exports or methods are added. FlagMetadata.id and FlagMetadata.version are now None when a record has none, instead of 0; events already left a falsy ID and version out, so event properties do not change.
  • FeatureFlag.from_json(), FlagMetadata.from_json() and FlagReason.from_json() build the subclass they are called on, as in 7.82.1.
  • The supplied payload for number and object values is compact JSON in wire key order. Decoded payloads are identical to v=2; only float exponent spelling can differ (1e-07 here, 1e-7 from the server).
  • Compatibility, beyond the reason text below: FlagMetadata.id and FlagMetadata.version widen from int to Optional[int] (absent stays None instead of 0), and a record with a known field of the wrong JSON type, such as a string reason, an empty-string reason or a non-boolean has_experiment, now reads as described in point 2. Before, such a record was read anyway or failed the whole response. A value nested too deeply to encode also fails only its own record. When nothing is cached, two results for the unreadable flag itself differ from 7.82.1, where the whole response failed: a single-flag getter returns False instead of None, and a snapshot read of it sends its event without flag_missing. Its sibling flags now read normally instead of failing with it.
  • For flags in the new format, the server writes different reasons at v4 than at v2: both reason.description ("Matched rule 2" instead of "Matched condition set 2") and reason.code (no_rule_match instead of no_condition_match). FeatureFlagResult.reason and $feature_flag_reason show the v4 text once a server sends v4. Values do not change, and v1 flags are identical in every field.
  • FlagMetadata keeps no experiment_id or holdout_id. Contract 7.0.0 lets only config version 1 records carry metadata.experiment_id, so the reader checks that it is an integer (a wrong type fails the record) and does not keep it.
  • Memory, for an evaluate_flags() snapshot of 5,000 flags: 2.87 MiB without payloads (7.82.1: 1.50 MiB) and 7.95 MiB with 1 KiB payloads (7.82.1: 20.9 MiB). The extra cost without payloads is the parsed record each flag keeps.
  • The SDK compliance adapter declares the flags_response_v4 capability, and CI runs harness 1.23.0, which checks flags_request_uses_v4_query_param instead of the ?v=2 check.
  • The flags service already serves v4, so this can be released once it is approved. The changeset is minor.

💚 How did you test it?

  • posthog/test/test_flags_released_behaviour.py compares this SDK with results recorded by posthog 7.82.1. posthog/test/released_flags_scenarios.py uses only the public client API, so the same scenarios run on 7.82.1 (which recorded posthog/test/fixtures/released_flags_7_82_1.json; the module says how to regenerate it) and on this SDK, from both the v2 and the v4 response shapes. They cover every legacy getter, get_flags_decision, the snapshot getters, capture(flags=...), capture(send_feature_flags=True), every $feature_flag_called event, responses with an unreadable record, and fallback cache histories (good, then unreadable, then a timeout or error). The only expected differences are the absent ID and version and the unreadable flag's own value, and the test says which decision causes each one.
  • posthog/test/test_flags_v4_response.py (153 tests), built on fixtures copied unchanged from harness release 1.22.0. posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json records the release, revision and file digests, and a test checks them. It covers:
    • all valid producer responses, retained field by field;
    • older-server records, and the sync and async clients logging once per client;
    • wrongly typed known fields in both record shapes, records that are not objects, sibling isolation and unknown fields; failed records never render their value;
    • unreadable records with and without a cached value;
    • absent flag IDs and versions, and number or object values sent with a conflicting payload;
    • from_json() on subclasses;
    • legacy parity: the v1 legacy projection corpus and the typed value corpus render the same legacy values from a v2-shaped and a v4-shaped record.
  • Full suite (6,009 passed; the fork-based metrics tests are deselected locally because they crash on macOS with and without this change), ruff format --check, ruff check, mypy with the baseline filter, and the public API snapshot check.
  • Ran the compliance suite locally against harness 1.23.0, with the adapter in capture v0 and v1 modes: 56/56 and 121/121 passed, including flags_request_uses_v4_query_param.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Written by Claude Code, an AI agent, from a brief by the assignee. The agent extended the existing parsing path rather than adding a parallel one: the parsed FeatureFlag carries the typed value, and the legacy fields are derived from it once. An earlier revision moved the typed accessor API to #1147, so this change ships without new public methods. A later review round kept unreadable records out of the fallback cache, kept the unknown_error 7.82.1 reported for them, built from_json() subclasses again, reduced snapshot memory, and pinned legacy results to a 7.82.1 recording instead of comparing the new SDK with itself. Choices the reviewer may want to revisit are listed under "Notes for reviewers".

🤖 Generated with Claude Code

Request /flags?v=3 from the sync and async clients and keep each flag's
typed value, structured reason and metadata on the parsed record.

- A record with a value member is a v3 record. A record without one comes
  from an older server: its value is variant, or enabled without one, and
  its config version is 1.
- A known field with the wrong JSON type fails only that record with
  PARSE_ERROR; unknown fields are ignored.
- Legacy getters, callbacks and $feature_flag_called keep reading the
  legacy rendering, now derived from value in one place: a string is the
  variant, null is disabled, and a number or object is enabled with the
  value as a JSON-encoded payload.
- evaluate_flags() snapshots gain get_boolean/string/number/object_value
  and _details accessors. They never coerce and return the caller default
  for missing, failed, null and mismatched values.
@andehen andehen self-assigned this Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-11-02T19:40:56.890595+00:00
Duration: 260109ms

✅ All Tests Passed!

121/121 tests passed


Capture_V1 Tests

✅ 95/95 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 517ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 511ms
Required Headers.Has Authorization Bearer Header ✅ 511ms
Required Headers.Has Content Type Json ✅ 510ms
Required Headers.Has Posthog Sdk Info Format ✅ 510ms
Required Headers.Has Posthog Attempt Header ✅ 510ms
Required Headers.Has Posthog Request Id ✅ 511ms
Required Headers.Has Posthog Request Timestamp ✅ 510ms
Required Headers.Has User Agent ✅ 512ms
Body Format.Body Has Created At And Batch ✅ 511ms
Body Format.No Api Key In Body ✅ 510ms
Body Format.No Sent At In Body ✅ 510ms
Event Format.Event Has Required Root Fields ✅ 511ms
Event Format.Event Uuid Is Valid ✅ 510ms
Event Format.Event Timestamp Is Rfc3339 ✅ 510ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 526ms
Event Format.Distinct Id Is String ✅ 511ms
Event Format.Distinct Id At Root Not Properties ✅ 510ms
Event Format.Custom Properties Preserved ✅ 510ms
Event Format.Set Properties Preserved ✅ 511ms
Event Format.Set Once Properties Preserved ✅ 510ms
Event Format.Groups Properties Preserved ✅ 510ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 510ms
Event Format.Event Has Required Root Fields Batch ✅ 514ms
Event Format.Event Uuid Is Valid Batch ✅ 514ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 514ms
Event Format.Distinct Id Is String Batch ✅ 514ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 514ms
Event Format.Custom Properties Preserved Batch ✅ 513ms
Event Format.Set Properties Preserved Batch ✅ 514ms
Event Format.Set Once Properties Preserved Batch ✅ 513ms
Event Format.Groups Properties Preserved Batch ✅ 514ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 515ms
Batch Behavior.Multiple Events In Single Batch ✅ 518ms
Batch Behavior.Batch Envelope Smoke ✅ 515ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 507ms
Batch Behavior.Flush At Triggers Batch ✅ 1011ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 510ms
Deduplication.Generates Unique Uuids ✅ 518ms
Deduplication.Different Events Same Content Different Uuids ✅ 512ms
Deduplication.Preserves Uuid On Retry ✅ 6520ms
Deduplication.Preserves Timestamp On Retry ✅ 6520ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6522ms
Deduplication.No Duplicate Events In Batch ✅ 520ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 511ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13520ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6520ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3020ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6519ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 511ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 514ms
Response Format Validation.Success No Retry After When All Ok ✅ 513ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1516ms
Response Format Validation.Success No Retry After When Drop Only ✅ 513ms
Response Format Validation.Response Echoes Request Id ✅ 510ms
Retry Behavior.Retries On 408 ✅ 6520ms
Retry Behavior.Retries On 500 ✅ 6516ms
Retry Behavior.Retries On 503 ✅ 8523ms
Retry Behavior.Retries On 504 ✅ 6520ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3517ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11523ms
Retry Behavior.Does Not Retry On 400 ✅ 2513ms
Retry Behavior.Does Not Retry On 401 ✅ 2513ms
Retry Behavior.Does Not Retry On 402 ✅ 2513ms
Retry Behavior.Does Not Retry On 413 ✅ 2513ms
Retry Behavior.Does Not Retry On 415 ✅ 2512ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2514ms
Retry Behavior.Implements Backoff ✅ 22536ms
Retry Behavior.Max Retries Respected ✅ 22537ms
Partial Batch Handling.Handles 200 Full Success ✅ 2513ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3518ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3514ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3517ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6517ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6522ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6523ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6520ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6517ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6518ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8522ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3515ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3520ms
Compression.Sends Gzip Content Encoding ✅ 511ms
Compression.No Content Encoding When Disabled ✅ 510ms
Compression.Compressed Body Is Decompressible ✅ 511ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2512ms
Event Options.Cookieless Mode Override ✅ 510ms
Event Options.Disable Skew Correction Override ✅ 511ms
Event Options.Process Person Profile Override ✅ 511ms
Event Options.Product Tour Id Override ✅ 511ms
Event Options.Unset Options Omitted ✅ 510ms
Event Options.Options Override In Batch ✅ 514ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 511ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 510ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 512ms

Capture_Ai Tests

✅ 5/5 tests passed

View Details
Test Status Duration
Routing.Capture Ai Posts To Ai Endpoint ✅ 511ms
Routing.Capture Does Not Reroute Ai Named Events ✅ 510ms
Identity.Capture Ai Event Has Uuid ✅ 511ms
Identity.Capture Ai Keeps Supplied Uuid ✅ 511ms
Timestamp Format.Non Utc Event Timestamp Is Converted To Utc ✅ 511ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 11ms
Request Payload.Flags Request Uses V4 Query Param ✅ 10ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 9ms
Request Payload.Flags Request Omits Authorization Header ✅ 9ms
Request Payload.Token In Flags Body Matches Init ✅ 10ms
Request Payload.Groups Round Trip ✅ 9ms
Request Payload.Groups Default To Empty Object ✅ 9ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 10ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 9ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 9ms
Request Lifecycle.No Flags Request On Init Alone ✅ 4ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 510ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 15ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 9ms
Retry Behavior.Retries Flags On 502 ✅ 314ms
Retry Behavior.Retries Flags On 504 ✅ 313ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 512ms

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 71ms
Versioned Boolean Matching.Matching Version 1 ✅ 69ms
Versioned Boolean Matching.Matching Version 2 ✅ 70ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 38ms

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

A v3 record for a flag in the new format carries no experiment ID: an
experiment is linked to its rule on the server, by rule ID. A complete
experiment split is config version 2, reason experiment_split, rule type
experiment, a rule ID and a variant key.

- Remove experiment_id from FlagMetadata. An experiment_id key in a record
  is now ignored like any other unknown metadata key, whatever its type.
- The vendored harness 1.13.1 fixtures still carry the field. Two reader
  cases that depend on it are overridden in the test until the fixtures are
  re-pinned to a contract without it.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 811c6c2: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

Comment thread posthog/types.py Resolved
A v3 record carries no holdout ID: holdouts in the new format are local to
their rule and have no ID, and older records never sent one. A holdout
result is identified by its rule ID and the reason holdout.

- Remove holdout_id from FlagMetadata. A holdout_id key in a record is now
  ignored like any other unknown metadata key, whatever its type.
- The vendored harness 1.13.1 fixtures still send the field. No reader case
  depends on it; the producer-response test skips it, like experiment_id,
  until the fixtures are re-pinned to a contract without it.
@github-actions

Copy link
Copy Markdown
Contributor

This PR was closed due to lack of activity. Feel free to reopen if it's still relevant.

@github-actions github-actions Bot closed this Oct 23, 2026
@andehen andehen reopened this Oct 26, 2026
Clients already send v=3 and arbitrary v values, so the server selects
the typed record only for an exact v=4. Request v=4 and name the record
after it in tests, docstrings and the changeset. A server that answers
v=4 with the older record (enabled/variant, no value) is still read the
same way.
@andehen andehen changed the title feat(flags): read the /flags?v=3 response with typed values feat(flags): read the /flags?v=4 response with typed values Oct 26, 2026
@github-actions github-actions Bot removed the stale label Oct 27, 2026
Align the evaluate_flags() typed accessors with the typed flag values
API proposed in PostHog/sdk-specs#104.

- FlagEvaluationDetails has an OpenFeature reason (FlagEvaluationReason)
  and a flag_metadata dict of the record's id, version, config_version,
  reason_code, condition_index, rule_type, rule_id and variant_key. It no
  longer carries the structured reason or the metadata object, so the
  payload stays on the payload accessors.
- variant is set only when the accessor returns the flag's value.
- Locally evaluated flags read with config_version 1 and a reason derived
  from the value, or DISABLED for an inactive flag.
- Each client logs once when /flags sends records without typed values,
  and a TYPE_MISMATCH message says why the value was not returned.
- Add get_integer_value/details and get_float_value/details. Object values
  are returned as copies.
- Re-vendor the rules v2 contract fixtures from posthog-sdk-test-harness
  1.22.0 (contract 7.0.0). A config version 1 record may carry an integer
  metadata.experiment_id, so its type is checked; it is not kept.
The adapter sends /flags?v=4, so it declares flags_response_v4. A harness
that knows the capability runs flags_request_uses_v4_query_param in place
of the ?v=2 check; older harness releases ignore it.
# Conflicts:
#	posthog/client.py
#	posthog/feature_flag_evaluations.py
Keep reading the /flags?v=4 response and retaining each flag's typed value,
reason and metadata, with the existing getters derived from the value. Drop
the public typed accessors (get_boolean_value/_details and the string,
number, integer, float and object forms), FlagEvaluationDetails,
FlagEvaluationReason and FlagEvaluationErrorCode, and the error code and
message fields on FeatureFlag that only they read. They return in a
separate change once the cross-SDK accessor API is agreed.
@andehen andehen changed the title feat(flags): read the /flags?v=4 response with typed values feat(flags): read the /flags?v=4 response Oct 29, 2026
A record without a typed value now has its known fields type-checked like a
/flags?v=4 record, so a wrong-typed field fails only that flag instead of the
whole response. A key member of the wrong type fails its record; the map key
stays the flag key. An unreadable record keeps the field that failed it, apart
from a record the server marks failed.
A record without metadata.id or metadata.version no longer reads them as 0.
Events already left out a falsy ID and version, so their properties do not
change.
A /flags?v=4 record with a number or object value exposes that value as its
payload, even when the record also sends a payload. Other payloads are kept as
sent.
A value nested too deeply for json.dumps used to abort the whole
response. Also test an empty-string reason, and the async client with
unreadable and sparse records beside a healthy one. The changeset now
says which results can differ from before.
get_flags_decision() returns FeatureFlag and FlagMetadata, so public fields
could be adopted before the typed flag API is settled. The typed accessors
make them public.
andehen added a commit that referenced this pull request Oct 30, 2026
#1031 keeps the v4 value and rule metadata fields private. The typed
accessors make them public, so this branch keeps them public and the
changeset lists them.
…ields

The typed value, failure flags and rule metadata were declared as dataclass
fields, so repr, equality, hashing, dataclasses.asdict and the generated SDK
reference showed them. Keep them as instance attributes with class-level
defaults instead, import FeatureFlag privately in the snapshot module, and
say in the changeset that the details stay internal.
@andehen
andehen marked this pull request as ready for review November 2, 2026 18:01
@andehen
andehen requested a review from a team as a code owner November 2, 2026 18:01

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed ed04b1b: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

Comment thread posthog/types.py Resolved
A /flags record with a known field of the wrong type reads as disabled.
The single-flag getter cached that result, replacing the last good value,
so a later failed request fell back to False. An unreadable record now
leaves the fallback cache alone and falls back to its value, like a failed
request does; without a cached value it still reads as disabled.
FeatureFlag.from_json delegated to record parsers that always built a
FeatureFlag, so a subclass calling it got a base instance. The parsers
now take the class to build, for v4, older and unreadable records alike.
A snapshot kept each remotely evaluated flag twice: a flattened record and
the parsed /flags record, with the payload both as its JSON text and
decoded. The snapshot record now reads its fields from the parsed record
and decodes the payload on its first read, then keeps it. Private record
details that equal their defaults are no longer stored on each instance.

Retained memory of evaluate_flags() for a /flags?v=4 response of 5,000
boolean flags (tracemalloc, Python 3.14, including JSON decoding):

  no payloads:              3.64 MiB -> 2.87 MiB
  1 KiB JSON payload each: 28.10 MiB -> 7.95 MiB

A payload that is read is decoded and kept as before, so reading every
payload retains about what it did before this change.
The parity tests compare this SDK's v2 and v4 readers with each other, so
a change shared by both passes. These tests compare both with results
recorded once with posthog 7.82.1 for the same flags: the legacy getters,
get_flags_decision, snapshot reads, $feature_flag_called properties and
fallback-cache histories. Where a result differs from 7.82.1 by decision,
the test says why: an absent flag ID or version reads as None, not 0, and
a record with a field of the wrong type fails only itself.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 77b0354: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

A single-flag read of a record that cannot be read used to fail the whole
request, so its $feature_flag_called event carried
$feature_flag_error: unknown_error and no request id or evaluated-at. The
read now succeeds for the other records, but this one still reports the
same error and no request details, so a value served from the fallback
cache stays distinguishable from a fresh one.

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks good; no qualifying findings. Verdict: correct.

@andehen
andehen merged commit e094dc5 into main Nov 3, 2026
44 checks passed
@andehen
andehen deleted the feat/flags-v3-response branch November 3, 2026 12:59
eli-r-ph pushed a commit that referenced this pull request Nov 4, 2026
eli-r-ph added a commit that referenced this pull request Nov 4, 2026
* feat(flags): read the /flags?v=4 response (#1031)

(cherry picked from commit e094dc5)

* chore: Release v7.83.0 [skip ci]

(cherry picked from commit 43c7545)

* Update generated references

(cherry picked from commit 314fad0)

* fix(mcp): inject context into mcp 2.x tool objects and test without fastmcp (#1166)

* test(mcp): share the dual-major structured content reader

* test(mcp): read structured content without the fastmcp camelcase shim

* fix(mcp): inject context into mcp 2.x tool objects

* ci: run the mcp 2.x suite without fastmcp

(cherry picked from commit 0c12ca1)

* chore: Release v7.83.1 [skip ci]

(cherry picked from commit 9f5d458)

* Update generated references

(cherry picked from commit 06e5611)

* test(flags): read flag scenario events in before_send on v1

v1 passes flag properties to _enqueue separately and layers them while
building the event, so a stubbed _enqueue never saw them. Reading the
built event in before_send compares v1's real output with 7.82.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Anders <754494+andehen@users.noreply.github.com>
Co-authored-by: releaser-posthog-python[bot] <262414804+releaser-posthog-python[bot]@users.noreply.github.com>
Co-authored-by: Connor Grady <50903811+connor-grady@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants